Skip to content

Fix/version week - #533

Open
whes1015 wants to merge 34 commits into
mainfrom
fix/version-week
Open

Fix/version week#533
whes1015 wants to merge 34 commits into
mainfrom
fix/version-week

Conversation

@whes1015

Copy link
Copy Markdown
Member

這個 PR 做了什麼

相關 issue

  • closes #

怎麼驗

檢查清單

  • tool/check_commits.sh origin/main..HEAD 通過
    —— commit 訊息就是更新日誌,格式見 commit.md
  • 一個 commit 一件事(這條 gate 驗不了,靠自己和 review)
  • mise exec -- flutter analyzemise exec -- flutter test 通過
  • 新的使用者可見字串都走 AppLocalizations,沒有寫死
  • 有 UI 變更的話:用的是 AppSpacing / AppRadius / AppMotion
    深色模式看過,文字對比度可接受

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

🔍 OpenCodeReview found 13 issue(s) in this PR.

  • ✅ Successfully posted inline: 11 comment(s)
  • ❌ Failed to post inline: 2 comment(s)

⚠️ 1 warning(s) occurred during review.


maintainability · low

📄 lib/features/changelog/data/changelog_api.dart (L80-L83)

⚠️ GitHub could not post this as an inline comment: No server is currently available to service your request. Sorry about that. Please try resubmitting your request and contact us if the problem persists.

getAvatarBytes 目前沒有處理網路請求可能產生的異常(例如 404 或網路中斷)。建議確認呼叫端是否有完善的錯誤處理機制,或者在此處加入 try-catch 並回傳 null,以避免非核心的頭像讀取失敗導致整個功能崩潰。

💡 Suggested Change

Before:

  Future<Uint8List> getAvatarBytes(String login) async {
    final payload = await _client.getBytesAbsolute(avatarUrlFor(login));
    return payload.bytes;
  }

After:

  Future<Uint8List?> getAvatarBytes(String login) async {
    try {
      final payload = await _client.getBytesAbsolute(avatarUrlFor(login));
      return payload.bytes;
    } catch (_) {
      return null;
    }
  }

bug · medium

📄 lib/shared/map/map_timeline.dart (L116-L124)

⚠️ GitHub could not post this as an inline comment: Unprocessable Entity: "Line could not be resolved" - https://docs.github.com/rest/pulls/reviews#create-a-review-for-a-pull-request

_bigLabel 中,當 widget.framePeriod 不為 null 時,end 時間使用的是預設的 _time (HH:mm) 格式,而 start 時間使用的是 widget.timeFormat (如果有的話)。這會導致當使用者自定義了 timeFormat 時,顯示的時間範圍格式不一致(例如:"10:00:00 – 11:00")。建議在 _bigLabel 中也使用與 _times 相同的格式。此外,由於 _cacheLabels 只在 frames 改變時才重新計算,如果 widget.timeFormat 改變但 frames 未變,_times 的格式將不會更新。建議在 didUpdateWidget 中也檢查 timeFormat 是否改變。

💡 Suggested Change

Before:

  String get _bigLabel {
    final start = _times[_liveIndex];
    final period = widget.framePeriod;
    if (period == null) return start;
    final end = _time.format(
      widget.frames[_liveIndex].time.toLocal().add(period),
    );
    return '$start – $end';
  }

After:

  String get _bigLabel {
    final start = _times[_liveIndex];
    final period = widget.framePeriod;
    if (period == null) return start;
    final format = widget.timeFormat ?? _time;
    final end = format.format(
      widget.frames[_liveIndex].time.toLocal().add(period),
    );
    return '$start – $end';
  }

  @override
  void didUpdateWidget(covariant MapTimeline oldWidget) {
    super.didUpdateWidget(oldWidget);
    final framesChanged = !identical(oldWidget.frames, widget.frames);
    final formatChanged = oldWidget.timeFormat != widget.timeFormat;
    if (framesChanged || formatChanged) _cacheLabels();
    // ...
  }

⚠️ Warnings:

  • lib/features/changelog/domain/release_note.dart (comment_refiled): comment filed against lib/features/changelog/presentation/widgets/release_contributors.dart describes code in lib/features/changelog/domain/release_note.dart; re-filed

Comment on lines +643 to 676
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Filled, not outlined: the one active affordance on a page
// whose every other row is an outlined icon.
Container(
width: 44,
height: 44,
width: 34,
height: 34,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: gold.badge,
),
child: Icon(Icons.favorite, color: gold.onBadge, size: 24),
child: Icon(Icons.favorite, color: gold.onBadge, size: 19),
),
Text(
l10n.sponsorTitle,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: gold.ink,
const SizedBox(width: AppSpacing.sm),
Flexible(
child: Text(
l10n.sponsorTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: gold.ink,
),
),
),
const SizedBox(width: AppSpacing.xs),
Icon(
Icons.chevron_right,
size: 14,
color: gold.ink.withValues(alpha: 0.7),
),
],
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
根據程式碼註解的設計意圖,「右側欄位應呈現為一個對齊的堆疊(the right column reads as one aligned stack)」,意指後方的箭頭(trailing arrow)應該在垂直方向上對齊。

然而,目前的實作使用了 MainAxisAlignment.center 搭配 Flexible,這會導致:

  1. 當文字較短時,整個 Row 的內容會集中在中間,箭頭的位置會隨文字長度而左右移動,無法形成右側對齊的「列」。
  2. Flexible 不會強制佔用剩餘空間,只有在文字過長需要截斷時才會縮減。

建議改為:

  1. MainAxisAlignment.center 改為 MainAxisAlignment.start (或移除,因為預設即為 start)。
  2. Flexible 改為 Expanded。這樣文字會填滿中間的剩餘空間,將箭頭強制推至 Row 的最右側,從而達成註解所述的「右側對齊堆疊」效果。

Suggestion:

Suggested change
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Filled, not outlined: the one active affordance on a page
// whose every other row is an outlined icon.
Container(
width: 44,
height: 44,
width: 34,
height: 34,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: gold.badge,
),
child: Icon(Icons.favorite, color: gold.onBadge, size: 24),
child: Icon(Icons.favorite, color: gold.onBadge, size: 19),
),
Text(
l10n.sponsorTitle,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: gold.ink,
const SizedBox(width: AppSpacing.sm),
Flexible(
child: Text(
l10n.sponsorTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: gold.ink,
),
),
),
const SizedBox(width: AppSpacing.xs),
Icon(
Icons.chevron_right,
size: 14,
color: gold.ink.withValues(alpha: 0.7),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Filled, not outlined: the one active affordance on a page
// whose every other row is an outlined icon.
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: gold.badge,
),
child: Icon(Icons.favorite, color: gold.onBadge, size: 19),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
l10n.sponsorTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: gold.ink,
),
),
),
const SizedBox(width: AppSpacing.xs),
Icon(
Icons.chevron_right,
size: 14,
color: gold.ink.withValues(alpha: 0.7),
),
],
),

Fix(zh-Hant): 修正週一上午建置的版本號會標成上一週
Fix(en-US): fix a Monday-morning build being named for the previous week

The week came from UTC, and the people reading the label are eight hours
ahead — so every Monday between 00:00 and 08:00 Taipei fell in the previous ISO
week. `26w33e` was built at 07:47 that Monday and the build an hour later
became `26w34a`: two consecutive snapshots, a week apart by name. Report dates
already use Asia/Taipei (api.md); the label now does too.
Optimization(zh-Hant): 贊助卡片改為橫式排版,與右欄其他卡片對齊更好看
Optimization(en-US): the sponsor card now row-aligns with the right column
The More page will lead the version card with a stable anchor number: a
release names itself and a snapshot names the release it builds toward.
That value — the newest v* tag, v stripped — now rides every build the
same way the label does, through version.sh, the generated build info,
and CI's dart-define.
New(zh-Hant): 更多頁上方卡片重新設計,版本以漸層數字顯示,pre-release 顯示上一版主版本號
New(en-US): the More hero cards go flat, and the version card leads with a gradient major.minor named after the last release
Fix(zh-Hant): 修正圖層時間軸在某些時區會顯示成 UTC 時間
Fix(en-US): map timelines now show frame times in the device's local time instead of UTC
The version card's big number is the train (26.1) — the release a snapshot
is cut toward — not the previous release tag. version.sh already derives
it; this replaces the last-release pipeline with a train pipeline end to
end: script output, generated build_info, the AppBuild surface, and the
dart-defines CI stamps.

No user-visible change yet; the card itself lands in the next commit.
The support, Discord and announcement cards each centred their row, so
every icon and label started where its own row's text happened to end.
Now they all start at the same left margin and the trailing arrows sit at
the same right margin.

Fix(zh-Hant): 修正「更多」頁上方三張卡片的圖示與文字起點對齊
Fix(en-US): align the icons and text of the three hero cards on the More page
The version card now reads 26.1 first — the train every build rides — and
a snapshot names itself beside it in smaller print (26w34a). Both are
larger than before so the number is the thing the eye lands on.

New(zh-Hant): 「更多」頁版本卡改以版號 26.1 為主,測試版同時標示完整版號
New(en-US): the version card on the More page leads with 26.1 and shows the full snapshot name beneath it
New(zh-Hant): 更多頁英雄區加入伺服器狀態卡,並縮小版本卡
New(en-US): Add the server-status card to the More hero block and slim the version card
@whes1015
whes1015 requested a review from a team as a code owner August 17, 2026 09:08
Comment on lines +596 to 648
static const double _versionHeight = 176;

/// Height of a small card (Discord, announcement, status) and, matching it,
/// the full-width support card below.
static const double _smallCardHeight = 56;

@override
Widget build(BuildContext context) {
return SizedBox(
height: _height,
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
0,
AppSpacing.lg,
AppSpacing.md,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(flex: 1, child: _VersionCard()),
const SizedBox(width: AppSpacing.md),
Expanded(
flex: 1,
child: Column(
children: const [
Expanded(flex: 2, child: _SupportCallout()),
SizedBox(height: AppSpacing.xs),
Expanded(flex: 1, child: _DiscordCallout()),
SizedBox(height: AppSpacing.xs),
Expanded(flex: 1, child: _AnnouncementCard()),
],
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
0,
AppSpacing.lg,
AppSpacing.md,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SizedBox(
height: _versionHeight,
child: const _VersionCard(),
),
),
),
],
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
children: const [
SizedBox(
height: _smallCardHeight,
child: _DiscordCallout(),
),
SizedBox(height: AppSpacing.xs),
SizedBox(
height: _smallCardHeight,
child: _AnnouncementCard(),
),
SizedBox(height: AppSpacing.xs),
SizedBox(height: _smallCardHeight, child: _StatusCard()),
],
),
),
],
),
const SizedBox(height: AppSpacing.md),
SizedBox(height: _smallCardHeight, child: const _SupportCallout()),
],
),
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
UI 佈局的高度適應性風險。_HeroCards 使用了硬編碼的高度(_versionHeight = 176, _smallCardHeight = 56)。在不同螢幕尺寸或使用者設定較大字體時,這些固定高度可能導致內容(如文字或圖示)發生垂直溢出(Overflow)或顯示不全。

Comment on lines +958 to +975
static List<Color> _hashGradient(String seed, Brightness brightness) {
var h = 7;
for (final rune in seed.runes) {
h = (h * 31 + rune) & 0x7fffffff;
}
final base = h % 360;
const saturation = 0.62;
final light = brightness == Brightness.dark ? 0.70 : 0.46;
return [
HSLColor.fromAHSL(1, base.toDouble(), saturation, light).toColor(),
HSLColor.fromAHSL(
1,
(base + 137.508) % 360,
saturation,
light - 0.10,
).toColor(),
];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
_hashGradient 函數中使用了多個「魔法數字」(例如:黃金角度 137.508、飽和度 0.62、亮度偏移 0.10 等)。建議將這些設計參數提取為具備明確名稱的私有常數,以提升程式碼的可讀性與維護性。

Comment on lines +120 to +122
final end = _time.format(
widget.frames[_liveIndex].time.toLocal().add(period),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
widget.timeFormat 與預設的 _time 不同時,start 會使用 widget.timeFormat 格式化,而 end 會使用 _time 格式化,導致顯示的範圍標籤格式不一致。建議在計算 end 時也使用 widget.timeFormat ?? _time

Suggestion:

Suggested change
final end = _time.format(
widget.frames[_liveIndex].time.toLocal().add(period),
);
final format = widget.timeFormat ?? _time;
final end = format.format(
widget.frames[_liveIndex].time.toLocal().add(period),
);

New(zh-Hant): 版本卡片正式版也會顯示完整版本號
New(en-US): The hero version card now shows a version line for releases too
New(zh-Hant): 新版號 26.2.1 的版型只顯示 26.2
New(en-US): A three-part release (26.2.1) advertises as 26.2; the patch stays in the label
New(zh-Hant): 版本卡片徽章加上 build 日期
New(en-US): The More page badge shows the day the build was cut
New(zh-Hant): 版本徽章右側顯示 build 日期
New(en-US): The version badge now carries the day this build was cut
Comment on lines 74 to +76
static Future<void> ensureLoaded() async {
if (_label != null) return;
String platformVersion = '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · low
ensureLoaded 方法目前的冪等性檢查依賴於 _label != null。如果 _bestLabel 為空或 _bestCode 不大於 0(例如在某些開發環境下),_label 將保持為 null,導致每次呼叫 ensureLoaded 都會重新執行非同步的 PackageInfo.fromPlatform()。建議使用一個獨立的布林值(如 _isLoaded)來標記載入狀態,以避免重複的 IO 操作。

Suggestion:

Suggested change
static Future<void> ensureLoaded() async {
if (_label != null) return;
String platformVersion = '';
static bool _isLoaded = false;
static Future<void> ensureLoaded() async {
if (_isLoaded) return;
String platformVersion = '';
try {
final info = await PackageInfo.fromPlatform();
platformVersion = info.version;
} on Object {
// A version readout is never worth failing a launch over. The platform
// version line simply stays empty for that build.
}
_platformVersion = platformVersion;
if (_bestLabel.isNotEmpty && _bestCode > 0) {
_label = _bestLabel;
_code = _bestCode;
}
_isLoaded = true;
}

Comment on lines +625 to +639
child: Column(
children: const [
SizedBox(
height: _smallCardHeight,
child: _DiscordCallout(),
),
SizedBox(height: AppSpacing.xs),
SizedBox(
height: _smallCardHeight,
child: _AnnouncementCard(),
),
SizedBox(height: AppSpacing.xs),
SizedBox(height: _smallCardHeight, child: _StatusCard()),
],
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
_HeroCards 的佈局使用了固定的高度(如 _versionHeight_smallCardHeight)來包裹包含文字內容的組件(例如 _DiscordCallout_AnnouncementCard_StatusCard_VersionCard)。這在 Flutter 中存在嚴重的佈局溢出(Overflow)風險,特別是當使用者在系統設定中啟用了「大字體模式」(Font Scaling),或者在不同語言的本地化過程中,文字內容長度增加導致高度超過預設值時。建議考慮使用 IntrinsicHeight 來讓 Row 中的元素高度同步,或者改用 BoxConstraintsminHeight 而非固定 height,以允許組件根據內容自動增長。

Comment on lines +958 to +975
static List<Color> _hashGradient(String seed, Brightness brightness) {
var h = 7;
for (final rune in seed.runes) {
h = (h * 31 + rune) & 0x7fffffff;
}
final base = h % 360;
const saturation = 0.62;
final light = brightness == Brightness.dark ? 0.70 : 0.46;
return [
HSLColor.fromAHSL(1, base.toDouble(), saturation, light).toColor(),
HSLColor.fromAHSL(
1,
(base + 137.508) % 360,
saturation,
light - 0.10,
).toColor(),
];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
_hashGradient 函數透過版本字串動態生成顏色,雖然考慮了主題亮度(lightness flips with the theme),但這並不能完全保證產生的顏色與背景色(colors.surfaceContainer)之間具備足夠的對比度以符合無障礙設計標準(WCAG)。在極端情況下,生成的顏色可能與背景色過於接近,導致文字難以辨識。建議在生成顏色後,加入對比度檢查機制,或是在亮度(lightness)範圍上設定更保守的邊界。

Comment on lines +2048 to +2049
@override
String get moreTagline => 'Platform Integrasi Informasi Bencana';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
印尼文翻譯與英文原文語意不一致。英文定義為 'Disaster Prevention Information Platform' (災害預防資訊平台),但目前的印尼文翻譯為 'Platform Integrasi Informasi Bencana' (災害資訊整合平台),其中 'Integrasi' 意為 '整合' 而非 '預防' (Pencegahan)。建議修正以符合原文語意。

Suggestion:

Suggested change
@override
String get moreTagline => 'Platform Integrasi Informasi Bencana';
@override
String get moreTagline => 'Platform Informasi Pencegahan Bencana';

Comment on lines +218 to +222
final utc = DateTime.utc(2026, 7, 13, 14, 30); // 22:30 in UTC+8
final frames = [MapFrame(id: '0', time: utc)];
await tester.pumpWidget(
_wrap(frames: frames, selectedIndex: 0, onSelected: (_) {}),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · medium
該測試案例高度依賴於執行環境的時區。若在 CI 環境(通常時區設定為 UTC)中執行,utc.toLocal() 的結果會與 utc 完全相同。這意味著即使程式碼錯誤地直接顯示了 UTC 時間,測試依然會通過,從而無法達到攔截 Bug 的目的。建議選擇一個在轉換為本地時間後會明顯改變小時或日期的時間點(例如接近 UTC 當天結尾的時間),以增加測試在不同環境下的魯棒性。

Suggestion:

Suggested change
final utc = DateTime.utc(2026, 7, 13, 14, 30); // 22:30 in UTC+8
final frames = [MapFrame(id: '0', time: utc)];
await tester.pumpWidget(
_wrap(frames: frames, selectedIndex: 0, onSelected: (_) {}),
);
// 建議選擇一個轉換後容易產生差異的時間點,例如 UTC 23:30
final utc = DateTime.utc(2026, 7, 13, 23, 30);
final frames = [MapFrame(id: '0', time: utc)];
await tester.pumpWidget(
_wrap(frames: frames, selectedIndex: 0, onSelected: (_) {}),
);

New(zh-Hant): 首頁地區列下方浮一條金色支持橫條,點擊前往贊助頁
New(en-US): Home floats a gold support pill under the region bar, opening the sponsor page
New(zh-Hant): 繁體贊助相關文案改用「支援」
New(en-US): Traditional-Chinese support copy now reads 支援, not 支持
New(zh-Hant): README 新增 Android 測試版與 iOS TestFlight 連結
New(en-US): README now links the Android testing track and the iOS TestFlight beta
Fix(zh-Hant): 修正公測使用者可能被當成正式版使用者,收到比手上還舊的更新提示
Fix(en-US): fix a beta tester being treated as a stable user and offered an older build

`AppVersion.tryParse` stops at the first letter, so every snapshot of a year
parses to the same number — `26w34a` and `26w40a` are both `26`. The channel
was decided by matching that against a release tag, which finds *a* release of
that year rather than the one running, and only gave the right answer because
every `26w**` release happens to be a pre-release.

It breaks as soon as `v26.1` exists, or as soon as the running snapshot falls
off the fetched page. The fallback then decides, and it cannot help on Android:
internal, open testing and production all install through `com.android.vending`.
The channel is now read from the build ordinal, which is what identifies one
build; the version string stays as the path for builds older than the scheme.
Comment on lines +2048 to +2049
@override
String get moreTagline => 'Platform Integrasi Informasi Bencana';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
印尼文翻譯建議確認語意一致性。目前的翻譯為 "災害資訊整合平台" (Platform Integrasi Informasi Bencana),而英文版本為 "Disaster Prevention Information Platform",中文版本為 "防災資訊整合平台",兩者皆強調了「預防/防災」(Prevention) 的含義。建議確認是否應加入預防含義(例如使用 "Pencegahan Bencana"),以保持各語言間語意的一致性。

Suggestion:

Suggested change
@override
String get moreTagline => 'Platform Integrasi Informasi Bencana';
@override
String get moreTagline => 'Platform Integrasi Informasi Pencegahan Bencana';

Comment on lines +4886 to +4887
@override
String get moreTagline => '防灾信息整合平台';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
AppLocalizationsZhHans 繼承自 AppLocalizationsZh,由於父類使用了繁體中文用語(如 '支援')及繁體字體,且 AppLocalizationsZhHans 未重寫相關屬性,會導致簡體中文版本出現繁體中文內容,造成語言不一致。建議在 AppLocalizationsZhHans 中重寫 sponsorTitle, sponsorIntro 與 sponsorCalloutBody 並使用簡體中文。

New(zh-Hant): 更新日誌每張版本卡片下方新增 Contributors 頭像列
New(en-US): changelog cards foot a Contributors avatar strip

The strip is parsed from @Handles already in the release body, so no extra
API call; avatars stream through the ETag store as URL-addressed assets
like map tiles, so revisits are local reads.
A merge commit is invisible to the commit gate — `check_commits.sh` walks with
`--no-merges`, because a merge message is generated rather than written — so
anything that arrives through one is never judged. A branch that is behind is
a different hole: the gates that passed describe a tree nobody will ever have.

The message gate also now walks the branch's own tip rather than the checked-out
merge commit, and takes the base ref through the environment: a pull request
from a fork carries whatever branch name its author chose, and that was being
pasted into the shell.
return out;
}

final RegExp _atHandle = RegExp(r'@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · low
contributorsFromBody 使用了正則表達式 r'@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)' 來解析 @handle。雖然目前的模式看起來是為了匹配 GitHub 的用戶名規則,且結構相對簡單,但若未來的正則表達式變得複雜,應注意 ReDoS(正規表達式阻斷服務攻擊)的風險。目前此模式風險較低,但仍需保持警惕。

Comment on lines +29 to +32
@override
Widget build(BuildContext context) {
final contributors = contributorsFromBody(body);
if (contributors.isEmpty) return const SizedBox.shrink();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
ContributorStripbuild 方法中直接調用 contributorsFromBody(body)。雖然目前的實作相對簡單(使用 RegExp.allMatches),但如果 body 內容非常龐大,每次 Widget 重繪時都會重新執行正則表達式掃描,可能導致效能問題。建議考慮將解析結果緩存,或者將其作為參數傳入。

Comment on lines +33 to +57
final shown = contributors.take(_maxShown).toList();
final avatarWidth = 26 * shown.length - 6 * (shown.length - 1);
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.lg,
AppSpacing.md,
),
child: Row(
children: [
SizedBox(
width: avatarWidth.toDouble(),
height: 26,
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = 0; i < shown.length; i++)
Positioned(
left: (i * 20).toDouble(),
child: _Avatar(contributor: shown[i]),
),
],
),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
ContributorStrip 中的 avatarWidth 計算邏輯與 Stack 中的 Positioned 偏移量(i * 20)使用了硬編碼的數字(magic numbers)。這會增加維護成本,若未來調整頭像半徑或重疊間距,必須確保兩處邏輯同步更新,否則會導致佈局錯誤。建議將這些數值提取為常數或基於頭像半徑計算。

Comment on lines +304 to 319
builder: (context, dials, _) => SafeArea(
bottom: false,
child: Column(
children: [
RegionBar(
blend: dials.blend,
dismiss: dials.dismiss,
skyIsLight: skyIsLightFrom(sky, weatherMode),
),
_GoldSupportBar(
blend: dials.blend,
dismiss: dials.dismiss,
),
],
),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
使用 SafeArea 包裹 Column 可能會導致 RegionBar 的佈局位置發生非預期的偏移。由於 RegionBar 位於 Column 的頂部,而 SafeArea 預設會根據設備的狀態列(Status Bar)添加頂部內邊距,這可能導致原本緊貼螢幕頂部的 RegionBar 被向下推移,破壞 UI 設計。建議確認是否需要 top: false

Suggestion:

Suggested change
builder: (context, dials, _) => SafeArea(
bottom: false,
child: Column(
children: [
RegionBar(
blend: dials.blend,
dismiss: dials.dismiss,
skyIsLight: skyIsLightFrom(sky, weatherMode),
),
_GoldSupportBar(
blend: dials.blend,
dismiss: dials.dismiss,
),
],
),
),
builder: (context, dials, _) => SafeArea(
bottom: false,
top: false, // 建議明確設定 top: false 以確保 RegionBar 仍可緊貼頂部
child: Column(
children: [
RegionBar(
blend: dials.blend,
dismiss: dials.dismiss,
skyIsLight: skyIsLightFrom(sky, weatherMode),
),
_GoldSupportBar(
blend: dials.blend,
dismiss: dials.dismiss,
),
],
),
),

Comment on lines +338 to +395
class _GoldSupportBar extends StatelessWidget {
const _GoldSupportBar({required this.blend, required this.dismiss});

final double blend;
final double dismiss;

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final gold = AppGold.of(context);
final hidden = (blend + dismiss).clamp(0.0, 1.0);
return IgnorePointer(
ignoring: hidden > 0.9,
child: Opacity(
opacity: 1 - hidden,
child: FractionalTranslation(
translation: Offset(0, -6 * dismiss),
child: Material(
color: gold.badge,
borderRadius: BorderRadius.zero,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.pushNamed(AppRoutes.sponsor),
child: SizedBox(
height: 30,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Flexible(
child: Text(
l10n.sponsorTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: gold.onBadge,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 2),
Icon(
Icons.chevron_right,
size: 16,
color: gold.onBadge.withValues(alpha: 0.85),
),
],
),
),
),
),
),
),
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
新組件高度依賴於外部資源,包括本地化字串 l10n.sponsorTitle、主題屬性 AppGold.of(context) 以及路由 AppRoutes.sponsor。根據檢查,這些定義在專案中已存在(例如 l10n.sponsorTitle 在多國語言檔中都有定義,AppRoutes.sponsor 也在路由中定義),因此運行時錯誤的風險較低。

Comment on lines +348 to +353
final hidden = (blend + dismiss).clamp(0.0, 1.0);
return IgnorePointer(
ignoring: hidden > 0.9,
child: Opacity(
opacity: 1 - hidden,
child: FractionalTranslation(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · medium
_GoldSupportBar 的互動體驗可能存在問題。組件利用 blenddismiss 的值來同時控制 OpacityIgnorePointer。目前的邏輯設定在 hidden > 0.9 時即停止接收點擊,這意味著當組件透明度仍有約 10% 時就已無法點擊,可能會造成使用者「看得到卻點不到」的困惑。建議調整 IgnorePointer 的閾值,使其與 Opacity 的變化更同步,或是在透明度較高時才禁用點擊。

Comment on lines +2047 to +2048
@override
String get moreTagline => 'Disaster Prevention Information Platform';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
lib/l10n/gen/app_localizations_en.dart 屬於自動生成的檔案,手動修改此類檔案會導致下次重新生成本地化資源時,您的變更被覆蓋掉。建議應透過修改原始的 .arb 檔案(例如 lib/l10n/app_en.arb)來完成此變更。

Comment on lines +2057 to +2059
@override
String get moreTagline =>
'Platform para sa Integral na Impormasyon sa Kalamidad';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
此檔案似乎是自動生成的(位於 lib/l10n/gen/)。手動在此檔案中新增 moreTagline 可能會導致下次執行生成腳本時改動被覆寫。此外,在 lib/l10n/app_fil.arb 中找不到 moreTagline 的定義。建議將新增的內容寫入 lib/l10n/app_fil.arb 檔案中,然後重新執行生成工具。

Comment on lines +2003 to +2004
@override
String get moreTagline => '防災資訊整合平台';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · high
警告:此檔案位於 lib/l10n/gen/ 目錄下,這通常是一個由工具(如 flutter gen-l10n)自動生成的檔案。直接手動修改此檔案是不正確的做法,因為下次執行生成指令時,這些修改會被原始的資源檔(例如 .arb 檔)內容完全覆蓋。

建議將 moreTagline 的定義以及文字內容的更動(例如「支持」改為「支援」)移至對應的原始 .arb 檔案中,然後重新執行生成指令。

Comment on lines +269 to +272
final label = AppBuild.label;
final stable = RegExp(r'^\d+\.\d+$').hasMatch(label);
expect(find.text(AppBuild.train), findsWidgets);
if (stable) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
test/features/more/more_page_test.dart 中,版本卡片的測試邏輯依賴於 AppBuild.label 的正則表達式來判斷版本類型。這增加了測試對 AppBuild 內部字串格式的耦合度,若未來版本號格式變動,可能導致測試失效。建議透過 AppBuild.debugSet 明確模擬不同版本的狀態,而非透過字串比對來推斷。

expect(label, isNot(v['train']));
} else {
expect(label, v['train']);
expect(v['train'], matches(r'^\d+\.\d+$'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
測試斷言被弱化了。原本的測試會驗證當 label 是兩部分版本(例如 26.1)時,它必須與 train 相等。修改後的版本僅檢查 train 的格式,失去了對 label 與 train 一致性的驗證。根據程式碼註解「A two-part label (26.1) already is its own train」,兩者應該是相等的。

Suggestion:

Suggested change
expect(v['train'], matches(r'^\d+\.\d+$'));
expect(v['train'], label);
expect(label, matches(r'^\d+\.\d+$'));

Fix(zh-Hant): 修正在「更多」點開 App 日誌後畫面狂刷並卡死
Fix(en-US): fix the app freezing after opening the log screen from More

Reporting an error is not free of consequence: it goes to Talker, whose stream
the log screen rebuilds on and the persister writes to disk from. So a fault
raised *while rendering that screen* — a layout overflow is the everyday one —
re-enters through the rebuild it just caused, and every turn adds a Crashlytics
report marked fatal and a database write.

The screen already dodged one known overflow by not using Talker's own app bar,
but dodging one does not close the circle. A repeated fault is now reported
eight times and then dropped until it stops for five seconds, per fault, so a
distinct error is never hidden by another. The notice saying so goes through
`info`, which cannot be the next turn of the loop.

The screen also no longer rebuilds once per line: it takes the history on a
400 ms tick, so a list cannot rebuild under a finger that is scrolling it.
Optimization(zh-Hant): App 日誌改用內建畫面,多了搜尋、等級篩選與分享
Optimization(en-US): the log screen gains search, level filtering and sharing

The hand-rolled layout existed to dodge an overflow in `TalkerScreen`'s header,
because an overflow is logged and the screen rebuilt on every line — a loop
that ended in a hang. That loop is cut where it starts now, so rebuilding the
screen ourselves bought nothing and cost the search, the filter, the sharing
and the settings that come with the real one. Three l10n keys went with it.

The row ceiling drops from 20000 to 5000 and moves into the insert's own
transaction. It was only applied by the hourly sweep, and a fault that logs
every frame writes faster than any sweep runs — so the table could not be
bounded between them, which is exactly the case the ceiling is for.
Fix(zh-Hant): 修正日誌頁把同一個錯誤不斷印到主控台,回放的紀錄也不再遺失等級
Fix(en-US): stop the log screen reprinting one fault forever, and keep replayed levels

Two faults, both mine, both from the change that stopped the screen freezing.

The repeat check sat *after* `FlutterError.presentError`, so suppression
covered the log, the crash report and the database but not the terminal: the
same fault printed every frame while the stored record stayed clean, which is
exactly how it was described — the data was fine and the console was unusable.
It is checked first now, and the first few still print, because that dump is
what makes a layout fault findable.

And a replayed line was titled `stored`, which threw away the level it was
written with. Talker colours a card and the level filter narrows by `logLevel`,
so every line read back out of the table was uncoloured and unfilterable — the
two things the screen is read with.
Fix(zh-Hant): 修正開啟日誌頁會把整份紀錄重印到主控台,並重複寫回資料庫
Fix(en-US): opening the log no longer reprints the whole table or writes it back

`logCustom` was the obvious way to get stored lines onto the screen and the
wrong one. It publishes to Talker's stream — which the persister writes from —
and prints to the console when console logs are on. So opening the screen
reprinted the entire table to the terminal *and* wrote every line back into the
table it had just been read from, leaving a duplicate behind on each visit.
The read limit made it worse by asking for 5000 lines into a history that holds
1000, so most of that work was evicted as it arrived.

`Log.replay` writes to Talker's history and nothing else. The screen is built
only once the replay is in, because history is read at build time.

A replayed card also read `log`, because that is what `TalkerData.title`
defaults to; it carries the level's own name now.
Fix(zh-Hant): 修正日誌頁的清除只清畫面,重開又整份回來
Fix(en-US): clearing the log no longer leaves everything to come back on reopen

The screen's clear button calls `talker.cleanHistory()`, which empties the
in-memory list and nothing else. The screen replays the `logs` table on every
visit, so everything came straight back from a table the button never touched —
the one thing it is pressed for is the one thing it did not do.

Talker's history is an interface, and this app already supplies its own, so
clearing it now clears the table with it.
Fix(zh-Hant): 修正日誌頁上方等級篩選器把回放的紀錄全算成 undefined
Fix(en-US): the level filter counts replayed lines under their level again

The screen groups its filter chips — and their counts — by `TalkerData.key`,
and colours a card by the same value. Not by the level, and not by the title.
A replayed line carried neither, so every stored line of every level collapsed
into one chip labelled `undefined`, uncoloured.

`Log.replay` also fills in the title and pen from that key, which is what the
logger does on the way past and what skipping it skipped.
Fix(zh-Hant): 修正開啟日誌頁會把本次啟動的紀錄擠掉,順序也錯亂
Fix(en-US): opening the log no longer pushes out the running session's lines

Talker's history appends and evicts from the front, so replaying a day of
stored lines pushed the live session out — `DPIP starting up` among them — and
left the older lines sitting where the newer ones should be. Reading 1000 into
a history that holds exactly 1000 made it certain.

That is backwards twice over. The stored log is on disk and can be read again,
the running session cannot; and lines older than everything in memory belong in
front of it, not appended after. Replay now inserts at the front and only into
free space.
Fix(zh-Hant): 修正啟動早期的紀錄從來沒被存下來,當機時最該看的那幾行都不見
Fix(en-US): fix the launch's own log lines never being stored, the ones a crash needs

Merging the stored log into what was already in memory produced a fault every
time it was touched: a loaded line evicting the running session, older lines
sitting after newer ones, a duplicate left behind on each visit. All of it came
from keeping two records and reconciling them.

There is only one record. Every line is persisted, so memory held nothing the
table did not — except the lines from before the database opened, which is the
gap that made replacing unsafe and is a hole in its own right: `DPIP starting
up` is logged at bootstrap.dart:111 and the store opens at :139, so everything
that explains a crash during launch was in memory and nowhere else. Those are
copied in now, and the screen loads the table over the top instead of merging.
Optimization(zh-Hant): 日誌的等級標籤改成大寫,一眼看得出哪一行不是 INFO
Optimization(en-US): log lines are tagged INFO / WARN / ERROR, readable at a glance

A level is a label, not prose, and it reads as a column when a hundred lines
are scanned for the one that is not `INFO`. `WARN` rather than `WARNING` keeps
the five that matter within a character of each other, so the messages after
them line up.

Display only. The `level` column still stores the enum's own name, so a stored
line parses back to the level it was written with.
Optimization(zh-Hant): 主控台的日誌改成一則一行,不再有框線與跑掉的顏色碼
Optimization(en-US): console logs are one plain line each, without borders or stray colour codes

The default logger draws every entry inside a box and paints it with ANSI
escapes. Neither survives the trip: `flutter run` prefixes each line with
`flutter: `, so a three-line box becomes three prefixed lines around one
message, and the escapes arrive as the literal text `^[[38;5;4m` because
nothing on that pipe reads them. What was meant as colour read as noise.

Colour was never delivered rather than lost — `enableColors` can go back on if
the output is ever read somewhere that renders it.
Optimization(zh-Hant): 主控台的等級標籤可以上色,用 --dart-define=DPIP_LOG_COLOR=true 開啟
Optimization(en-US): the console level tag can be coloured with --dart-define=DPIP_LOG_COLOR=true

A choice rather than a detection, because it cannot be detected: the bytes are
written on the device and read in whatever window is attached to `flutter run`,
which the app cannot see. VS Code's Debug Console prints ANSI literally — that
is where `^[[38;5;4m` came from — and its integrated terminal renders it. Same
build, different window.

`TalkerLogger` forces `ansiColorDisabled = false` in its constructor whatever
stdout is, which is why the escapes were emitted to a pipe that could not read
them at all. Off by default now, and only the tag is painted when it is on: a
fully coloured line is harder to read than a plain one, and a leak then costs
one short token instead of the whole line.
Fix(zh-Hant): 修正 iOS 上開啟主控台顏色只會多出跳脫字元,看不到顏色
Fix(en-US): enabling console colour on iOS added escape characters instead of colour

The flag was written as a choice because a terminal's capability cannot be
detected from inside the app. On iOS there is nothing to choose: the platform's
log path escapes the escape character itself, so a terminal that does support
ANSI still receives a backslash followed by the sequence and prints it. That is
flutter/flutter#20663, and it is why turning the flag on looked like it did
nothing but add noise.

That part *is* detectable, so it is detected. Elsewhere the flag still means
what it said.
Optimization(zh-Hant): 新增 tool/colorize_logs.sh,在終端機端替日誌上色
Optimization(en-US): add tool/colorize_logs.sh, which colours the log in the terminal

The app writes plain text and should keep doing so. On iOS an escape sequence
cannot survive the trip: the platform's log path escapes the escape character
itself, so a terminal that fully supports ANSI still receives a backslash
followed by the sequence and prints it — flutter/flutter#20663.
`dart:developer`'s `log` does deliver them, but truncates anything past ~128
characters to `<collected>`, and the long lines are the diagnostic ones, so it
buys colour with the content.

A pipe has neither problem, because the bytes are written by the terminal that
knows whether ANSI works — and it turns itself off when the output is not one.
It drops the `flutter: ` prefix too, which is a terminal's width back.

    mise exec -- flutter run | tool/colorize_logs.sh
Optimization(zh-Hant): 新增 tool/run.sh,一個指令跑起來就有上色的日誌
Optimization(en-US): add tool/run.sh so one command runs the app with a coloured log

    tool/run.sh -d "iPhone 17 Pro"

Hot reload still works. The tool reads `supportsColor` from stdout and its
keystrokes from stdin, and a pipe only touches the first — what is lost is
flutter's own colour and its progress spinner, which are redraw sequences a
pipe turns into litter anyway.

`pipefail` is the part a wrapper like this usually gets wrong: without it the
pipeline reports the colouriser's status, a failed build exits 0, and the
wrapper hides the thing it wraps. A test builds a stub that exits 7 and checks
the wrapper does too.
Fix(zh-Hant): 修正結束執行時出現 Broken pipe 的未處理例外
Fix(en-US): fix the unhandled Broken pipe exception when a run is stopped

Ctrl-C reaches every process in the foreground group, so the filter died first
and `flutter run` — still shutting down, still printing — wrote into a closed
pipe and reported EPIPE as an unhandled exception, stack trace and all. The
filter now ignores the interrupt and reads until its stdin closes, which is
when the writer has genuinely finished.

`tool/run.sh` is also the only supported way to start the app now, and a debug
build says so when it was started any other way. Both alternatives work and
both are wrong invisibly: a bare `flutter run` resolves whatever SDK the
shell's PATH cached — `mise activate` does not refresh it when mise.toml
changes — and the log arrives uncoloured either way.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants